Skip to content

fix(docs): serve prerendered pages from the static-assets incremental cache, and shrink the Worker under the 64 MiB limit - #273

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-261-worker-size-second-attempt
Sep 4, 2026
Merged

fix(docs): serve prerendered pages from the static-assets incremental cache, and shrink the Worker under the 64 MiB limit#273
os-project-manager merged 4 commits into
mainfrom
claude/issue-261-worker-size-second-attempt

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Part of #261. The mechanism behind #265 is established and fixed here too — that card can be closed by hand once someone agrees with the account below; no closing keyword, deliberately.

This is not PR #263 re-applied. It contains PR #263's change, plus the thing that was actually broken, plus a gate so it cannot come back. The first section is the evidence that these are two different defects.

The diagnosis: async: true did not break rendering

Nobody had diagnosed the 2026-09-04 outage. The record said "PR #263 merged, upload accepted, site broke, rolled back", and the only account of why was a guess that the new thing was the cause.

It was not. main was already unservable before async: true existed on it, and had been for nine days.

Run against real workerd (opennextjs-cloudflare preview), using this repository's own .github/scripts/smoke-docs.mjs — the check that gates production, which asserts an h1 with matching text, a visible-prose floor measured after script and style are stripped, a same-site link floor, the document language and the final path:

tree smoke-docs.mjs /, /en/docs, /docs/quickstart, /docs/build/interface/views
base main @ d6f5dda, no async: true 21 findings all four 404
main + async: true only (= PR #263) 21 findings all four 404, identically
this PR 0 findings all four render, negative control still red

The middle row is the exoneration. The failing tree in row 1 does not contain the change that was blamed and reverted.

Why every page 404s

Every page on this site lives under app/[lang]/, so every page route is a dynamic route prerendered through generateStaticParams() — 1139 paths in the prerender manifest.

OpenNext runs Next in minimal mode: Next does not read prerendered HTML off a filesystem, it asks the configured incremental cache for it. defineCloudflareConfig() called with no arguments resolves incrementalCache to "dummy", and the dummy cache's get() throws IgnorableError('"Dummy" cache does not cache anything') on every call. So the lookup for a prerendered page always misses.

What happens after the miss is decided by one route-segment flag:

  • dynamicParams unset (Next's default, true) — the miss falls through to an on-demand render. Every request re-renders the page, wastefully but correctly, and the site works.
  • dynamicParams = false — Next refuses the on-demand render and raises NoFallbackError, which OpenNext answers with the prerendered _not-found route. The page 404s.

Observed directly in the workerd log on unmodified main:

✘ [ERROR] Uncaught Error: Internal: NoFallbackError
      at responseGenerator (.../handler.mjs)
      at handleRevalidate (.../handler.mjs)

and on the wire, x-nextjs-prerender: 1 with x-nextjs-cache: MISS and a 404 body.

export const dynamicParams = false was added to app/[lang]/layout.tsx, app/[lang]/docs/[[...slug]]/page.tsx and app/og/docs/[...slug]/route.tsx on 2026-08-26, across five separate PRs about 404 semantics (#190, #192, #209, #211, #213), each correct in itself. The last accepted deploy was 2026-08-25 — the Worker went over the 64 MiB limit that evening and every upload afterwards was rejected. So the flag sat on main for nine days and never reached production.

PR #263's merge produced the first accepted upload in nine days. It published nine days of merged work, one item of which turns every page into a 404.

Confirmed against the live site, which this container cannot reach, by dispatching the smoke workflow onto a runner (run 33886707832): /docs/quickstart?os261-cachebust=a1 — a URL that has certainly never been requested before, so no CDN copy of it can exist — returns 200 with h1 "Quickstart" and 9101 visible characters. The live Worker really does render on demand, because the version serving it (69c79ee3-..., from 8feb90db) predates dynamicParams = false. That is the whole reason the site is up.

What the previous round's ablation could and could not show

The round-1 dev's own post-mortem on #261 is right, and this PR agrees with it from the other direction: breaking a lazy thunk and watching /api/search go 200 → 500 proved module resolution, and /api/search renders no components, so it could never have failed on rendering. Every route that round measured byte-for-byte — /api/search, /llms.txt, /sitemap.xml, /og/* — reads page bodies without rendering them.

None of those routes are used as rendering evidence here. The evidence above is rendered HTML judged by the production smoke check, plus a real browser (below).

The fix

staticAssetsIncrementalCache reads prerendered entries straight out of the Workers static assets this Worker already binds as ASSETS, under cdn-cgi/_next_cache — a prefix only the Worker can reach. opennextjs-cloudflare deploy copies .open-next/cache into .open-next/assets before uploading, and preview does the same locally.

  • No new infrastructure and no spend — no R2 bucket, no KV namespace, no new binding, no plan change.
  • Its one documented restriction, read-only and for apps that "do NOT want revalidation and ONLY want to serve prerendered data", is exactly this app: revalidate = false on every route handler, no ISR, no on-demand revalidation.
  • All 1139 prerendered routes have a cache entry, cross-checked against the prerender manifest — 0 missing, so there is no partial-outage tail.
  • x-nextjs-cache goes MISS → HIT on /docs/quickstart, /llms.txt and /sitemap.xml.
  • /cdn-cgi/_next_cache/.../quickstart.cache returns 404 to a public request. The 277 MB of prerendered payloads are not publicly enumerable.

A side effect worth naming: the Worker no longer renders a docs page at request time at all. await page.data.load() now runs only at build time, in Node — which is where the previous round could verify it. Proved by ablation rather than asserted: corrupting one page's cache entry produced a 404, not an on-demand render.

async: true is restored for its own, separate reason — the size defect #261 was filed about. Without it fumadocs-mdx eagerly imports all 397 .mdx files into every server entrypoint that touches source, and the bundler inlines that set five times over.

The gate that ships with it

This fix creates a new way to break the site silently, so it ships with the check for it. If .open-next/cache is ever absent or incomplete, the Worker publishes with its cache configured and empty: every lookup misses, dynamicParams = false refuses the render, and every page 404s — while the deploy step exits 0, because the upload succeeded, and while check-deploy-version.mjs passes, because a new version really is serving. It is just serving 404s.

.github/scripts/check-prerender-cache.mjs runs in deploy-docs.yml's deploy job, after the artifact download and before the deploy step, so a bad bundle is refused rather than published and then reported. It asserts against Next's own prerender-manifest.json from inside the bundle, not against a number anyone wrote down, so a page added to the corpus is covered the day it is added.

Demonstrated able to fail, twice. Fixtures: 8 cases covering all 7 rules, and the runner asserts every rule has a fixture that trips it, so weakening one exits 1. It is registered in tools/ci-scripts/run-self-tests.mjs, which independently fails by name on an unlisted script that declares a --self-test. And live, against a real 1139-route bundle:

intact     1139 prerendered, 1139 servable, 0 missing    EXIT 0
mutated    1139 prerendered, 1138 servable, 1 missing    EXIT 1  -> names /en/docs/quickstart
restored   1139 prerendered, 1139 servable, 0 missing    EXIT 0  (md5 f465ea19, byte-identical)

CI's own build path, exercised rather than assumed

Package the Worker and Upload the Worker bundle are push-only, so opennextjs-cloudflare build --skipNextBuild had never once executed with an incremental cache configured — its first run would have been the merge commit. That is the same shape as the defect this PR diagnoses, so it was measured instead of reasoned about: commit fb7259b temporarily added a pull_request clause and a probe job, and 3e037d6 reverted it, leaving .github/workflows/ci.yml byte-identical to before.

What CI run 33889336935 produced, on its own split-invocation artifact:

Package the Worker (--skipNextBuild)     success, cache present
artifact docs-worker                     93,597,106 B, sha256 7c354e53...
cache survived the round-trip            yes, with .open-next/.build/ intact
populateCache local                      "Successfully populated static assets cache"
assets after populate                    1697   (cache entries in assets: 1662)
prerendered routes                       1139
servable from static assets              1139
missing                                  0
wrangler deploy --dry-run                2260 files, Total Upload 58549.06 KiB

The probe's own check carried a failure branch (::error:: N route(s) would 404 in production, exit 1) — it is the same code now shipping as the permanent gate. CI on the final head ee99ae5 is green with those steps skipped again, which is what confirms the revert took.

Size, measured rather than projected

PR #263 projected ~52.9 MiB / 82.7% by scaling one local-versus-CI ratio on handler.mjs, and CI then measured 58541.00 KiB / 89.3%. Total Upload covers more than that one file, so this PR measures the bundle wrangler would actually upload.

reading KiB % of 65536 KiB
run #105, last accepted deploy before the outage 62747.87 95.75%
runs #106#141, rejected over 64 MiB
run #142 (async: true, measured in CI) 58541.00 89.33%
this PR, wrangler deploy --dry-run locally 58551.04 89.34%
this PR, wrangler deploy --dry-run in CI on the real artifact 58549.06 89.34%

The local dry-run and CI's agree to within 1.98 KiB, so the local figure was not a projection standing in for a measurement.

handler.mjs: 105,808,656 B → 50,827,882 B — 100.91 MiB to 48.47 MiB, a 52.0% cut. Note how little of that reaches Total Upload: 52.4 MiB comes off one file and the upload moves ~4 MiB. ⛔ Do not quote the −52% as the deploy's headroom; that is the error PR #263's projection made.

The ceiling is settled: 65536 KiB. #261 used both 64 MB and 64 MiB in one card, 1.5 MiB apart. Run #140's own rejection text reads "Your Worker exceeded the uncompressed size limit of 64 MiB" — Cloudflare's wording, so the denominator is 65536 KiB, this PR is at 89.34%, and headroom is 6986.94 KiB / 6.82 MiB. #262 can set its budget from that without re-deriving it.

Static assets are uploaded separately and do not count toward the Worker limit. The asset store goes from 35 files to 1697 (2260 as wrangler counts them) against a 20,000-file cap; largest single asset 795 KB against a 25 MiB cap.

Verified BEFORE merge

Everything below ran against opennextjs-cloudflare preview — real workerd, no Cloudflare credentials — on the built bundle, not next start.

  • smoke-docs.mjs green against local workerd, with its live negative control still going red in the same run. This is the first time this repository's rendering check has been run against a real Workers render before a merge; that is Prerendered docs page routes 404 under local wrangler dev / opennextjs-cloudflare preview, so no page can be smoke-tested in the Workers runtime before merge #265's ask.
  • All seven locales render with the right lang and localized headings: /zh-Hans/docs/quickstart 快速开始, /zh-Hant/docs/quickstart 快速開始, plus ja, de, es, fr, ko, each with a populated table of contents.
  • 30 randomly sampled prerendered page routes (seeded, drawn from the 1139) — 30/30 rendered with an h1.
  • Real browser (AGENTS.md rule 2), headless Chromium against the workerd preview, /docs/build/automation/approvals: h1 "Approvals", 7 h2, 18 table-of-contents anchors, 22 sidebar links, 6 rendered code blocks, 13558 visible characters. The same probe against the negative-control path returns the 404 shell — 138 visible characters, h1 "404" — so the browser check demonstrably discriminates.
  • CI's own --skipNextBuild path and the artifact round-trip, on a runner, with the numbers in the section above.
  • The new deploy gate, both fixture-driven and live, with a byte-identical restore.
  • Route handlers byte-identical to base: /api/search?query=approval 22890 B, /llms.txt 14693 B, /sitemap.xml 392938 B, /docs/quickstart.mdx 11909 B, one OG card 60871 B.
  • Routing and 404 semantics preserved: unknown slug 404, non-locale first segment 404 (fix(docs): reject a non-locale first segment at the route level #209), /cn/... 308, /EN/docs 308, /zh-hant/... 308.
  • Ablation on the served cache, with restore proven byte-identical. Corrupting one page's cache asset took /docs/quickstart from 200/178884 B to 404/11462 B while /docs/build/interface/views and /llms.txt stayed at their exact byte counts; md5 restore returned it to 200/178884 B. Mutation was confirmed on disk before any reading was taken.
  • Gates, exit codes captured before any pipe: type-check 0 · turbo run test --force 0 · check-locale-surface 0 (sitemap 409 URLs, 0 unexpected, 0 missing) · gen-zh-hant --check 0 (73 files byte-identical) · check-node-floor 0 · smoke-docs --self-test 0 · check-prerender-cache --self-test 0 · run-self-tests.mjs 0 (7 self-tests) · check-half-states --self-test 0 (1551 cases). CI green on the final head ee99ae5 (run 33890142955).

NOT verified before merge, and cannot be

  1. That Cloudflare accepts the upload. Measured at 58549.06 KiB in CI, 89.34% of the 65536 KiB ceiling; acceptance is still Cloudflare's call and the deploy only runs on main.
  2. The live site itself. This container gets 403 CONNECT tunnel failed for both docs.objectos.ai and the workers.dev host. Local workerd is the same bundle and the same ASSETS mechanism, but it is not the live edge.

The third item this list carried on the first draft — CI's own split build invocation — is no longer on it; it was measured, above.

⛔ The #269 verification layer is the backstop for 1 and 2, not the verification. It has never executed the chain deploy succeeds → smoke fails → rollback fires, and this PR is not the place to find out whether it works. What is different from the last attempt is that the assertion that failed last time — a docs page rendering under workerd — is no longer on this list.

Deviations

  • Two defects and a gate in one PR. async: true alone would keep the site 404ing; the cache alone would keep the upload rejected. Neither ships anything on its own, and separating them would mean merging one known-broken deploy on purpose. The gate is here because the fix creates the failure mode it guards.
  • .github and tools/ci-scripts are outside the dispatched apps/docs surface. Declared rather than widened quietly. The temporary CI probe and the permanent gate were both asked for during review, after the apps/docs change was accepted.
  • The temporary probe is on the record as two commits, not squashed away. fb7259b adds it, 3e037d6 reverts it, and git diff f35234f 3e037d6 -- .github/workflows/ci.yml is empty. Its evidence lives on CI run 33889336935.
  • Part of #261, not a closing keyword. Acceptance is a green Deploy Docs with a new version id and a green smoke check on the live site — both only exist after merge.
  • The smoke workflow was dispatched once against the live site as a read-only measurement (run 33886707832; workflow_dispatch cannot deploy, and file_issue defaults to false, so no card was filed). It shows failure, and the only findings are final-path on the two deliberately cache-busted probe URLs — the query string is not part of the final path. Both probes returned 200 and rendered; that run is the live-site evidence quoted above, not a regression.

Authorship

Written by Claude Code (session 01GkauAsZBEemRbco2rEX9Lx), https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx — as prose rather than a trailing footer block, because a PATCH edit of a pull-request body drops that block: the one this PR was created with did not survive the update that added the sections above. Recorded once, here, rather than re-posted on every edit.

… cache

The docs Worker has two independent defects. Only one of them was known.

## 1. Every page route is unservable, and has been since 2026-08-26

Every page lives under `app/[lang]/`, so every page route is a *dynamic*
route prerendered through `generateStaticParams()` — 1139 paths. OpenNext
runs Next in minimal mode, where Next does not read prerendered HTML off a
filesystem: it asks the configured incremental cache. `defineCloudflareConfig()`
with no arguments resolves `incrementalCache` to `"dummy"`, whose `get()`
throws by design, so that lookup always misses.

With `dynamicParams` unset the miss falls through to an on-demand render —
wasteful, but the site works. That is what the live Worker version
(`69c79ee3-...`, built from `8feb90db`) does, and it is why the site is up.

`export const dynamicParams = false` was then added to `app/[lang]/layout.tsx`,
`app/[lang]/docs/[[...slug]]/page.tsx` and `app/og/docs/[...slug]/route.tsx`
on 2026-08-26, across five separate PRs about 404 semantics. Under that flag
Next refuses the on-demand render and raises `NoFallbackError`, answered by
the prerendered `_not-found` route: the page 404s. Every page, every locale.

Cloudflare had already started rejecting the oversized upload the evening
before, so the flag never reached production and nothing showed it.

## 2. `async: true` was blamed for that and is innocent

PR #263 added `async: true`, the upload was accepted for the first time in
nine days, the site 404'd, and the flag was reverted. Measured on this tree
under real workerd, with the repository's own `.github/scripts/smoke-docs.mjs`:

  base `main`, no `async: true`      -> 21 findings, all four pages 404
  `main` + `async: true` only        -> the same 21 findings
  this commit                        -> 4/4 pages render, control still red

The size fix published a defect that was already merged. It did not make one.
`async: true` is restored here for its own reason: 2.50 MiB of authored MDX
was being inlined once per server entrypoint that touches `source`, five times
over, and `handler.mjs` measures 100.91 MiB without it against 50.83 MiB with.

## The fix

`staticAssetsIncrementalCache` reads prerendered entries out of the Workers
static assets this Worker already binds as `ASSETS`, under `cdn-cgi/_next_cache`
— a prefix only the Worker can reach (verified: that path 404s publicly). No
R2 bucket, no KV namespace, no new binding, no spend. Its documented
restriction, read-only and for apps that want no revalidation, is exactly this
app: `revalidate = false` on every route handler and no ISR anywhere.

All 1139 prerendered routes have a cache entry (cross-checked against the
prerender manifest, 0 missing), and `x-nextjs-cache` goes MISS -> HIT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
Reverted in the next commit on this branch. Pushed as its own commit so the
evidence and its removal are both on the record.

`Package the Worker` and `Upload the Worker bundle` are push-only, so
`opennextjs-cloudflare build --skipNextBuild` has never once executed with an
incremental cache configured — its first run would be the merge commit. If
that path does not produce or does not preserve `.open-next/cache`, the Worker
deploys with the cache CONFIGURED and EMPTY: every lookup misses,
`dynamicParams = false` refuses the on-demand render, and every page 404s.
That is the outage this branch diagnoses, reproduced by its own fix.

So the two steps gain a `pull_request` clause — the only difference from what
ships — and a temporary job downloads the artifact and runs
`opennextjs-cloudflare populateCache local`, which is exactly what
`opennextjs-cloudflare deploy` runs before `wrangler deploy` and, for the
static-assets cache, a filesystem copy needing no credentials. It then weighs
the bundle with `wrangler deploy --dry-run`, which gives #262 a CI-measured
number instead of one scaled from a local ratio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
The evidence it existed to collect is on CI run 33889336935 and quoted in the
PR body. `.github/workflows/ci.yml` is now byte-identical to f35234f, so this
branch ships only the three `apps/docs/**` files.

What the probe established, on CI's own `opennextjs-cloudflare build
--skipNextBuild` output rather than a local combined build:

  cache present after --skipNextBuild   yes (step exits non-zero if absent)
  artifact                              93,597,106 B, sha256 7c354e53...
  cache survived the round-trip         yes, with .open-next/.build/ intact
  populateCache local                   "Successfully populated static assets"
  prerendered routes                    1139
  servable from static assets           1139
  missing                               0
  Total Upload (wrangler dry-run, CI)   58549.06 KiB = 89.34% of 65536 KiB

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
The permanent home of the check the TEMPORARY probe two commits ago ran once.
Verified once is not verified.

## What it guards

`apps/docs` serves every page from a prerendered entry in `.open-next/cache`,
which `opennextjs-cloudflare deploy` copies into the uploaded assets. If those
entries are absent, the Worker publishes with its cache CONFIGURED and EMPTY:
every lookup misses, `dynamicParams = false` refuses the on-demand render, and
every page 404s — while the deploy step exits 0, because the upload succeeded.
`check-deploy-version.mjs` does not catch it either: a new version really is
serving. It is just serving 404s.

The cache is produced by a build invocation no pull request runs, travels to
the deploy job as an artifact, and is copied again by the deploy command.
Three places to lose it, none of which turn a step red on their own.

## Where it runs

In the `deploy` job, after the artifact is downloaded and BEFORE the deploy
step, so a bad bundle is refused rather than published and then reported. It
asserts against Next's own `prerender-manifest.json` from inside the bundle —
not a number anyone wrote down — so a page added to the corpus is covered the
day it is added, and the check cannot pass by comparing a stale expectation to
itself.

## Demonstrated able to fail, twice

Fixtures: 8 cases covering all 7 rules; `run-self-tests.mjs` asserts every rule
has a fixture that trips it, so weakening one exits 1. Registered there, which
that runner independently enforces — an unlisted script declaring a
`--self-test` fails it by name.

Live, against a real 1139-route bundle:

  intact    1139 servable, 0 missing   EXIT 0
  mutated   1138 servable, 1 missing   EXIT 1, naming /en/docs/quickstart
  restored  1139 servable, 0 missing   EXIT 0   (md5 f465ea19, byte-identical)

`shell: bash` on the step is load-bearing for the reason the verdict step below
it already documents: the default shell has no pipefail, so `node ... | tee`
would take tee's status and a gate exiting 1 would pass silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkauAsZBEemRbco2rEX9Lx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants